iT邦幫忙

2025 iThome 鐵人賽

DAY 2
0
Software Development

Codetopia 新手日記:設計模式與原則的 30 天學習之旅系列 第 2

Day 2:獨一無二的市長辦公室!—— Singleton 模式的權力與詛咒

  • 分享至 

  • xImage
  •  

1. 城市事件

Codetopia 創城初期,百廢待舉。一份「城市景觀色系綱要」的政策文件,需要下發到各個局處。

📜 文件內容: 「為統一市容,所有新建公共設施,主色調採 #333 現代灰。」

沒想到,意外發生了...

工務局收到的 email 副本寫著「現代灰」,但公園管理處的職員因為開會沒聽到,憑記憶寫了份筆記,變成了「復古金」。結果,城東的公車站是冷冽的灰色,城西的公園長椅卻是金光閃閃。市民們都看傻了,Codetopia 的美感瞬間變成一場災難!😱

這個混亂的根源是什麼?資訊來源不唯一。每個單位都有自己的一份「政策副本」,導致資訊不同步,最終城市建設亂了套。我們需要一個絕對的、唯一的、所有人都認可的資訊來源——市長辦公室

🧭 術語卡 (今日導覽)

  • GoF (Gang of Four):設計模式的經典著作,本文將其視為物件之間協作的「微觀」結構。
  • SSOT (Single Source of Truth):唯一真實來源。確保所有決策都基於同一份、無歧義的資訊,是 Singleton 模式要解決的核心問題之一。
  • DI (Dependency Injection):依賴注入。一種重要的解耦技術,將一個物件所依賴的外部服務(如市長辦公室)透過參數「注入」,而不是讓物件自己去尋找。

2. 違章建築味道 (反例/壞味道)

在還沒有建立「市長辦公室」這個概念前,我們的程式碼裡可能充斥著這樣的「違章建築」:

  • 街角公告欄 (Global Variables) 📜: 在程式碼的某個角落宣告一個全域變數 CITY_CONFIG。它看似方便,誰都能讀取,但誰也都能修改!今天工務局把它改成灰色,明天公園處就能改成金色,完全失控,是 bug 的溫床。
  • 信差滿天飛 (Props Drilling) 🏃‍♂️: 為了確保資訊一致,我們只好把一個巨大的 config 物件,從市長一路傳遞給局長,局長再傳給處長,處長再傳給科員... 即使最底層的科員只需要其中一個小設定,也得接收整份文件。這造成了荒謬的資訊冗餘和緊密的依賴鏈。
  • 多頭馬車 (Inconsistent Instantiation) 👨‍💼👨‍💼: 每個局處都自己 new 一個「政策顧問」物件。結果城市裡有 A 顧問、B 顧問、C 顧問,他們拿到的可能是不同時間點的政策版本,給出了完全矛盾的建議。
  • 地下情報站 (Service Locator) 🕵️: 一個看似方便的全域「服務查詢站」,任何物件都可以從中獲取 Singleton 實例。這雖然比裸的全域變數好一點,但本質上還是隱藏了依賴關係,讓測試和維護變得困難。

程式碼裡的混亂現場

讓我們用一小段 Python 程式碼,精準重現開頭那場「市容顏色災難」。這段程式碼聞起來就有一股濃濃的『違章建築』味,而且,它注定會驗收失敗

# 違章建築:每個局處都自己 new 一個「政策辦公室」
class PolicyOffice: # 注意:這還不是我們的 Singleton 市長辦公室
    def __init__(self):
        self.color_policy = None
    def set_policy(self, color):
        self.color_policy = color
    def get_policy(self):
        return self.color_policy

def public_works_dept_action():
    office = PolicyOffice()
    office.set_policy("#2E7D32") # 工務局根據自己的理解設定了綠色系
    return office.get_policy()

def parks_dept_action():
    office = PolicyOffice()
    office.set_policy("#1E88E5") # 公園處則設定了藍色系
    return office.get_policy()

# 驗收測試:這段測試注定會失敗!
print("--- 災難現場重現 ---")
pw_color = public_works_dept_action()
p_color = parks_dept_action()

try:
    assert pw_color == p_color
    print("✅ 咦?居然統一了?這不科學...")
except AssertionError:
    print(f"❌ 驗收失敗!市容分裂:工務局是 {pw_color},公園處是 {p_color}。")

看到那個 AssertionError 了嗎?這就是多頭馬車的直接後果。為了解決這個問題,我們需要引入今天的工法。

3. 今日工法 (核心觀念):Singleton (單例) 模式

Singleton 模式,就是 Codetopia 的市長辦公室設立法案。它是一種創生型模式,其核心思想極度專一:

確保一個類別只有一個實例,並提供一個全域的存取點來獲取這個實例。

就像 Codetopia 不能有兩個市長一樣,某些物件,我們也必須確保它們在記憶體中是獨一無二的。

何時用 (When to Use)

  • 唯一的真實來源 (Single Source of Truth):當你需要一個地方統一管理全域狀態或設定時。例如:城市的設定檔、日誌服務 (Logging Service)、資料庫連線池。大家都跟「市長」要資料,絕對不會出錯。
  • 管控昂貴或稀缺資源:有些物件的創建成本非常高(例如:需要讀取大型檔案、建立網路連線)。確保它只被創建一次,可以顯著提升效能。想像一下,全城共用一台超級昂貴的 3D 建築印表機,而不是每個局處都買一台。
  • 需要一個明確的全域協調者:例如,一個負責管理所有視窗的 Window Manager。

何時不要用 (When NOT to Use) ⚠️

  • 只是為了方便存取:如果你的物件不具備「天然唯一」的屬性,只是想偷懶、不想透過參數傳遞,而把它做成 Singleton,那你正在親手埋下技術債。這會讓它變成一個美化版的全域變數,使得程式碼之間的依賴關係變得隱晦,極難測試和維護。
  • 狀態頻繁變動的業務物件:一個代表「使用者訂單」或「購物車」的物件,顯然不應該是 Singleton。市長不該親自去管每個市民的菜籃子裡裝了什麼!
  • 需要高度可測試性的環境:Singleton 是單元測試的殺手之一。因為它的狀態是全域共享且持久的,一個測試案例修改了 Singleton 的狀態,可能會意外地影響到下一個測試案例,導致測試結果不穩定。更好的選擇是使用依賴注入 (Dependency Injection)

🏙️ 更簡單的替代方案:真的需要蓋市長辦公室嗎?

在動工前,先問問自己:我只是需要一個掛外套的衣帽架,還是真的需要一棟市政府大樓?

  • Python 模組即單例:在 Python 中,模組在第一次被匯入時會被執行並快取。因此,你可以直接在一個 config.py 檔案中定義變數和函式,任何地方 import config 都會存取到同一個實例,這是最 Pythonic 且簡單的方式。
  • 依賴注入 (DI):與其讓各局處自己去 MayorOffice.get_instance(),不如在局處成立時,由外部(例如一個中央的 AppBuilder)把市長辦公室的實例傳遞進去。這樣依賴關係一目了然,且在測試時可以輕鬆換成一個「代理市長」(Mock Object)。

懶漢 vs. 餓漢 (Lazy vs. Eager Initialization)

市長什麼時候上班?這決定了我們的 Singleton 類型。

類型 初始化時機 優點 缺點
懶漢式 (Lazy) 第一次呼叫 get_instance() 節省啟動時間與記憶體 首次呼叫較慢,多執行緒需處理同步問題
餓漢式 (Eager) 程式啟動時 (類別載入時) 執行緒安全,立即可用 拖慢啟動速度,即使完全沒用到也會佔用資源

4. 三層並置圖 (視覺化藍圖)

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Singleton (唯一實例+全域存取點) 市長辦公室 / 設定中心
中觀 (訊息/事件) 單一配置/目錄服務 (Actor: Registry) 城市設定服務 (Config Server)
宏觀 (MAS) DF (Directory Facilitator) 或治理代理 城市黃頁服務 (Directory Service)

如何閱讀這張藍圖?

這張圖的目的是將同一個「唯一權威」的概念,在三個不同的視角下對齊,幫助我們從單純的類別結構,思考到系統內的資訊流動與角色協作。

  1. 微觀 (GoF):先看類別之間如何互動來保證唯一性。
  2. 中觀 (訊息/事件):再看在一個系統中,各個部分如何與這個「唯一服務」溝通。
  3. 宏觀 (MAS):最後拉到整個分散式系統的視野,看看這個角色如何演變成一個基礎設施。

微觀:市長辦公室結構圖 (Class Diagram)

這張藍圖定義了市長辦公室的結構:它自己創建自己,並封鎖了其他人隨意建造的入口。

https://ithelp.ithome.com.tw/upload/images/20250917/20178500dzJmraJtrz.png

中觀:協作流程 (Sequence Diagram)

各局處(Client)如何向唯一的設定中心(Singleton)取得一致的資訊。

https://ithelp.ithome.com.tw/upload/images/20250917/201785003PTEnmW5p9.png

5. 最小實作 (施工藍圖)

在 Python 中,最穩固的 Singleton 施工法是利用 __new__,它在 __init__ 之前被呼叫,是真正控制物件創建的地方。

import threading

class MayorOffice:
    _instance = None
    _lock = threading.Lock()

    # __new__ 才是真正創建實例的地方
    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            with cls._lock:
                # Double-Checked Locking to ensure thread safety
                if not cls._instance:
                    cls._instance = super().__new__(cls)
        return cls._instance

    # __init__ 負責初始化,但要防止重複執行昂貴操作
    def __init__(self):
        # 使用一個旗標來確保初始化邏輯只執行一次
        if getattr(self, "_initialized", False):
            return

        self._policies = {}
        self.city_motto = "Build with passion, code with purpose."
        self._initialized = True
        print(f"市長辦公室 (ID: {id(self)}) 已建立並初始化。")

    # --- 政策管理 ---
    def set_policy(self, key, value):
        self._policies[key] = value

    def get_policy(self, key, default=None):
        return self._policies.get(key, default)

    @staticmethod
    def get_instance():
        """提供一個熟悉的靜態方法來獲取實例,這在本質上是 __new__ 的一個包裝"""
        return MayorOffice()

程式碼說明

  1. _instance 是一個類別變數,用來存放那個唯一的實例。
  2. __new__ 方法攔截了物件的創建過程。只有在 _instanceNone 時,才會真正創建一個新物件。
  3. _lock 和雙重檢查鎖確保了在多執行緒的混亂施工現場,也只會有一位市長誕生。
  4. __init__ 中的 _initialized 旗標確保了即使多次拿到同一個實例,昂貴的初始化設定(如讀取設定檔)也只會執行一次。

6. 回到現場:用 Singleton 修復「色系政策」事故

現在,我們有了 Singleton 這個強大的工法。讓我們回到最初的災難現場,用市長辦公室來發布統一政策,並執行我們一開始就寫好的驗收測試,看看它是否能神奇地『由紅轉綠』!

# 首先,我們讓各局處的行為改成向唯一的市長辦公室查詢
# --- 模擬城市各局處的運作 ---
def public_works_dept_task():
    mayor = MayorOffice.get_instance()
    color = mayor.get_policy("landscape_color")
    print(f"工務局收到政策,景觀色為: {color} (辦公室 ID: {id(mayor)})")
    return color

def parks_dept_task():
    mayor = MayorOffice.get_instance() # 同樣的 get_instance() 呼叫
    color = mayor.get_policy("landscape_color")
    print(f"公園處收到政策,景觀色為: {color} (辦公室 ID: {id(mayor)})")
    return color

# --- 執行與驗收 ---
print("\n--- 用 Singleton 工法進行修復與驗收 ---")

# 1. 市長發布統一命令
MayorOffice.get_instance().set_policy("landscape_color", "#333-Modern-Gray")

# 2. 執行我們在「違章建築」章節中,那個注定會失敗的驗收流程
pw_color = public_works_dept_task()
p_color = parks_dept_task()

try:
    assert pw_color == p_color
    print(f"\n✅ 驗收通過!市容終於統一為 {pw_color}。")
except AssertionError:
    print(f"\n❌ 驗收失敗!市容依然分裂:工務局是 {pw_color},公園處是 {p_color}。")

劇情收束:看到沒?我們完全沒有修改驗收邏輯,僅僅是改變了局處獲取資訊的方式(從 new PolicyOffice() 改為 MayorOffice.get_instance()),原先失敗的測試就通過了!這就是 Singleton 作為「唯一真實來源 (SSOT)」的威力。一場城市美學危機,就此化解。

7. 升維鉤子 (進階視野)

今天我們在「微觀」層面談論了 Singleton 物件。當我們將視角拉高:

  • 在中觀的事件驅動架構 (EDA) 中,Singleton 的概念演變成了像 ZookeeperConsul 這樣的「組態中心」或「服務註冊中心」。它不再是程式內的一個物件,而是一個網路中所有服務都去查詢的、唯一的真相來源 (SSOT)。
  • 在宏觀的多代理系統 (MAS) 中,這個角色由 DF (Directory Facilitator) 扮演,它是所有代理的「黃頁」,提供其他代理的地址和能力查詢服務,確保了整個代理社會的溝通秩序。

實務邊界提醒

  • 單程序 vs. 多程序:程式內的 Singleton 只保證在「單一進程」內唯一。如果你的應用程式啟動了多個進程(或部署在多台機器上),每個進程都會有自己的「市長」,這時就需要依賴外部系統(如 Redis、Zookeeper)來實現跨進程的唯一性。
  • 可變狀態的危險:如果你的 Singleton 內部狀態是可變的(像我們的 _policies),它就成了一個隱性的全域狀態機,非常容易在複雜系統中引發難以追蹤的 bug。一個更好的實踐是,讓 Singleton 提供的設定是不可變的 (immutable),任何變更都應該是生成一個新版本的設定,而不是原地修改。

8. 測試指北 (Testing Guide)

直接測試依賴 Singleton 的程式碼是個噩夢。有可能出現一種情況,當測試工務局時,市長的政策被改成了 "Holiday-Red";下一個測試公園處的案例,可能會因為讀到這個被污染的狀態而失敗。

解決方案

  1. 測試後重置:在你的測試框架中,使用 setupteardown 機制,在每個測試案例結束後,強制重置 Singleton 狀態。
# 僅供測試環境使用!
def _reset_mayor_singleton_for_tests():
    MayorOffice._instance = None
    MayorOffice._initialized = False
  1. 優先使用依賴注入 (DI):這是根本的解決之道。不要讓 parks_dept_task 自己去呼叫 MayorOffice.get_instance(),而是讓它接收一個 mayor 物件作為參數。這樣在測試時,你可以輕鬆傳入一個假的「代理市長」(MockMayor) 來完全控制測試情境。

9. 作業題 (動手實踐)

  1. DI 實踐:請將 public_works_dept_taskparks_dept_task 函式重構成依賴注入的形式 def task(mayor_instance): ...。你認為這樣做之後,編寫測試會有哪些具體的好處?
  2. 餓漢式實作:請嘗試將 MayorOffice 改寫成「餓漢式」版本。提示:可以在類別定義時就直接創建 _instance
  3. 反模式紅旗 🚩:在「違章建築味道」一節的基礎上,請描述一個具體情境,在該情境下,使用 Singleton 解決問題,但最終卻導致了更大的維護災難。

10. 結語

權力愈大,耦合愈重;慎用唯一,保持彈性。

今天,我們為 Codetopia 確立了唯一的權力核心,解決了政令不一的混亂。但我們也看到了 Singleton 這把雙面刃的鋒利之處。

明天,市長辦公室要開始正式運作了,第一個任務就是為市民核發身份證件。面對五花八門的證件申請,櫃檯人員該如何高效處理,而不用寫出成噸的 if-else 呢?敬請期待 Day 3:市民服務櫃檯的秘密——Factory Method!


上一篇
Day 1:你,是碼農還是建築師?—— Codetopia 創城計畫,啟動!
下一篇
Day 3:市民服務櫃台的秘密武器——Factory Method 搞定千變萬化的申請單!
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言